Skip to content

Fix in-process callback reclamation after connection close - #2610

Merged
stephentoub merged 7 commits into
mainfrom
stephentoub-fix-ffi-callback-teardown
Sep 11, 2026
Merged

Fix in-process callback reclamation after connection close#2610
stephentoub merged 7 commits into
mainfrom
stephentoub-fix-ffi-callback-teardown

Conversation

@stephentoub

Copy link
Copy Markdown
Collaborator

The Copilot CLI 1.0.84-4 runtime makes connection_close the callback-quiescence barrier: callback state can be reclaimed only when close returns true. The SDK adapters previously ignored that result, allowing native callbacks to race with freed or unregistered host callback state during disposal.

This change applies the contract consistently to all six in-process C ABI adapters: .NET, Node.js, Python, Go, Rust, and Java. Each adapter now retains callback ownership after a retryable false, retries cleanup after the active or reentrant callback can finish, reclaims callback state exactly once after a successful close, and defers host shutdown until quiescence. Binding exceptions retain callback ownership in a process-lifetime quarantine rather than risking use-after-free, while non-retryable host shutdown failures remain terminal.

The Node.js adapter also keeps its FFI keepalive active until Koffi callback unregistration is safe, then clears it so successful disposal does not keep the process alive. Focused .NET and Node.js regression tests cover false-then-true close behavior, exact-once reclamation, idempotent disposal, callback-initiated disposal, keepalive cleanup, and terminal shutdown results. Java lifecycle coverage was updated to model the native quiescence barrier.

Local builds and tests were intentionally not run. CI should validate the in-process .NET and Windows Node.js jobs, Node.js typecheck/unit tests, Java verify/Spotless/Checkstyle, and the in-process and lint/format jobs for Python, Go, and Rust.

Generated by Copilot

stephentoub and others added 2 commits September 10, 2026 16:46
Honor retryable C ABI connection close results before releasing callback state or shutting down the embedded runtime.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Limit retries to retryable connection-close results, preserve callback roots on binding failures, and align regression tests with the runtime quiescence contract.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings September 10, 2026 21:03
@stephentoub
stephentoub requested a review from a team as a code owner September 10, 2026 21:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Callback ownership can still be dropped incorrectly in Node.js and leaked after terminal Java shutdown, with several adapters lacking regression coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 1 Medium severity · 4 Low severity

New issues introduced by this change (6)
Severity Finding
High severity nodejs/​src/​ffiRuntimeHost.ts — Retain the callback token when unregistration fails
Medium severity java/​sdk/​src/​main/​java/​com/​github/​copilot/​ffi/​FfiRuntimeHost.java — Detach the Java callback after successful close
Low severity dotnet/​test/​Unit/​FfiRuntimeHostLifetimeTests.cs — Avoid testing lifecycle through private reflection
Low severity go/​internal/​ffihost/​ffihost.go — Cover Go's retryable close lifecycle
Low severity python/​copilot/​_ffi_runtime_host.py — Cover Python's retryable close lifecycle
Low severity rust/​src/​ffi.rs — Cover Rust's deferred close lifecycle
What changed in this PR

Updates all in-process SDK adapters to treat successful connection closure as the callback-quiescence barrier.

Changes:

  • Defers callback reclamation and host shutdown until close succeeds.
  • Adds retry and quarantine handling across six SDKs.
  • Adds focused Node.js, .NET, and Java lifecycle coverage.
File Description
rust/​src/​ffi.rs Adds deferred cleanup thread.
python/​copilot/​_ffi_runtime_host.py Adds synchronized cleanup retries.
nodejs/​src/​ffiRuntimeHost.ts Adds retry, quarantine, and keepalive cleanup.
nodejs/​test/​ffiRuntimeHost.test.ts Tests callback cleanup behavior.
nodejs/​tsconfig.test.json Includes the new lifecycle test.
go/​internal/​ffihost/​ffihost.go Defers callback registration cleanup.
dotnet/​src/​FfiRuntimeHost.cs Adds locked, retryable native cleanup.
dotnet/​test/​Unit/​FfiRuntimeHostLifetimeTests.cs Tests cleanup and terminal shutdown.
java/​sdk/​src/​main/​java/​com/​github/​copilot/​ffi/​FfiRuntimeHost.java Adds asynchronous cleanup retries.
java/​sdk/​src/​main/​java/​com/​github/​copilot/​ffi/​JnaNativeBinding.java Updates callback-lifetime documentation.
java/​sdk/​src/​test/​java/​com/​github/​copilot/​ffi/​FfiRuntimeHostTest.java Models the quiescence barrier.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread nodejs/src/ffiRuntimeHost.ts Outdated
Comment thread java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java
Comment thread dotnet/test/Unit/FfiRuntimeHostLifetimeTests.cs Outdated
Comment thread go/internal/ffihost/ffihost.go
Comment thread python/copilot/_ffi_runtime_host.py
Comment thread rust/src/ffi.rs
stephentoub and others added 3 commits September 10, 2026 17:15
Retain failed Koffi registrations, detach Java delegates at the close barrier, and add retryable-close lifecycle coverage for Go, Python, and Rust.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Guard debug logging from CA1873 and apply the Java Spotless wrapping required by CI.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment thread dotnet/test/Unit/FfiRuntimeHostLifetimeTests.cs Fixed
Comment thread dotnet/test/Unit/FfiRuntimeHostLifetimeTests.cs Fixed
Comment thread dotnet/test/Unit/FfiRuntimeHostLifetimeTests.cs Fixed
Comment thread dotnet/src/FfiRuntimeHost.cs
Comment thread dotnet/src/FfiRuntimeHost.cs Fixed
Comment thread dotnet/src/FfiRuntimeHost.cs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for #2610 · copilot · sonnet50 · 125.9 AIC · ⌖ 12.5 AIC · ⊞ 8.3K

Comment thread go/internal/ffihost/ffihost.go Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Cross-SDK Consistency Review — PR #2610

I compared the FFI runtime host lifecycle changes across all six SDKs (dotnet/src/FfiRuntimeHost.cs, go/internal/ffihost/ffihost.go, java/sdk/.../FfiRuntimeHost.java + JnaNativeBinding.java, nodejs/src/ffiRuntimeHost.ts, python/copilot/_ffi_runtime_host.py, rust/src/ffi.rs).

Verdict: consistent. This PR replaces the old "count active callbacks and spin-wait" drain strategy with a unified pattern in all six languages:

  • connection_close is now treated as the authoritative callback-quiescence signal. If it returns "not yet quiescent," cleanup (releasing the callback/host shutdown) is deferred and retried on a 100ms interval — the same interval constant is used everywhere (CleanupRetryDelayMilliseconds/CLEANUP_RETRY_INTERVAL_MS/_CLEANUP_RETRY_INTERVAL_SECONDS = 0.1/Go & Rust 100 * time.Millisecond / Duration::from_millis(100)).
  • host_shutdown returning false is now logged (debug/fine level) but treated as terminal — no retry — consistently in .NET, Go, Java, Node, and Python.
  • Startup now re-checks the disposed flag after the blocking native host_start/connection_open calls to correctly finalize cleanup if dispose()/Dispose()/close() raced with startup (.NET, Node, Python, Java all do this explicitly; Go achieves the same effect structurally since Start() and Dispose() share lifecycleMu and thus can't run concurrently; Rust's ownership model prevents the race by construction since no Arc<FfiShared> handle exists until after native setup completes).
  • A best-effort "quarantine" set is added in .NET, Java, Node, and Python to retain hosts whose native connection_close call itself threw/raised, since those runtimes can catch exceptions crossing the FFI boundary. Go and Rust don't add an equivalent, but that's consistent with their FFI conventions — both native bindings communicate exclusively via bool return values with no thrown-exception path, so there's nothing analogous to catch. This is a case of a legitimate language-specific difference (per the review guidelines' "language-specific optimization" carve-out), not a missing feature.
  • Each language ships new/updated unit tests exercising the "retains state until connection_close succeeds" and "does not retry a terminal host_shutdown failure" scenarios (FfiRuntimeHostLifetimeTests.cs, ffihost_test.go, FfiRuntimeHostTest.java, ffiRuntimeHost.test.ts, test_ffi_runtime_host.py, and the new #[test] callback_state_is_retained_until_connection_close_succeeds in rust/src/ffi.rs).

Naming follows each language's idiom (TryFinalizeNativeCleanup/tryFinalizeCleanupLocked/tryFinalizeCleanup/_try_finalize_cleanup/try_finalize_cleanup), and public API surface (start/dispose/close) is unchanged in every SDK.

No action items — nice work keeping this in lockstep across all six implementations.

Generated by SDK Consistency Review Agent for #2610 · copilot · sonnet50 · 93 AIC · ⌖ 12.3 AIC · ⊞ 8.3K ·

@SteveSandersonMS
SteveSandersonMS marked this pull request as draft September 11, 2026 09:25
auto-merge was automatically disabled September 11, 2026 09:25

Pull request was converted to draft

@SteveSandersonMS
SteveSandersonMS marked this pull request as ready for review September 11, 2026 09:26
@stephentoub

Copy link
Copy Markdown
Collaborator Author

@SteveSandersonMS Thanks for digging into this. The key distinction is queued replies vs. a C callback that is already executing.

copilot_runtime_connection_close is intentionally an untracked/discarding close. It latches the outbound gate and discards frames that have not entered the callback yet, so 2,000 outstanding requests do not by themselves make it return false. A first-call true in that scenario is expected. Likewise, higher-level replies can settle after close if their bytes had already crossed the callback boundary and were buffered in JS before close; the guarantee is about the C callback and its user_data, not completion of downstream stream/RPC processing.

The receiveStream.on("data") test is closer to the interesting case, but its assertion cannot distinguish the old and new SDK behavior. In Koffi 3.2.1, a callback invoked from a foreign thread calls napi_call_threadsafe_function(..., napi_tsfn_blocking) and then waits on a condition variable until the JS callback finishes (CallData::RelayAsync). The runtime's cabi-outbound thread therefore remains inside OutboundGate::dispatch while the JS data listener calls dispose(). Since close runs on the JS thread rather than the native dispatcher thread, the runtime waits up to its 5-second quiescence budget, returns false, and the callback finishes only after dispose() returns. On old SDK code, that false was ignored and the Koffi registration was unregistered anyway; that is a contract violation, but unregistering the callback that is already executing does not deterministically crash because Koffi has already loaded the JS function reference for that invocation. Timing the dispose() call should show the roughly 5-second timeout, but merely surviving does not prove reclamation was safe.

The deterministic bad window is narrower: native has entered/dispatched the callback, but the host has not yet safely acquired its callback state. A public SDK E2E cannot reliably pause at that instruction boundary without an instrumentation hook in the native callback. That is why the runtime change added event-driven gate tests and why these SDK tests inject false then true: they verify the host side of the now-explicit ABI contract rather than trying to turn a use-after-free race into a reliable black-box crash. The intermittent .NET CI failure (all 232 tests passed, then the testhost crashed during teardown) is consistent evidence, but I agree it is not a deterministic repro by itself.

On Rust specifically, removing active_callbacks strengthens the guarantee. The old counter increments inside on_outbound, after native has already dispatched the callback and passed the raw pointer. A close can observe zero and free the box while that callback is paused before its first increment/dereference; the callback then resumes into freed memory. Waiting for the counter only covers callbacks that already completed that unsafe dereference. The runtime gate increments in_flight before invoking the host callback and returns true only after it reaches zero, closing exactly that gap. Keeping the host counter would be redundant after a successful close and still would not repair the pre-increment race.

The authoritative contract and implementation are in github/copilot-agent-runtime@8228fb4bbc8: OutboundGate::dispatch records in_flight before invoking the host, OutboundGate::close returns Reentrant/TimedOut without authorizing reclamation, and copilot_runtime_connection_close returns true only for a known, quiescent gate. The commit message also documents the original Rust SDK teardown corruption and the runtime regressions that fail against the pre-fix implementation.

So I don't think we should add a slow, timing-based E2E that passes on both implementations. The focused adapter tests are intentionally contract tests for the only observable branch each SDK must handle: retain ownership on false, reclaim exactly once after a later true.

Generated by Copilot

@SteveSandersonMS SteveSandersonMS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — that all checks out, and the queued-replies vs. executing-callback distinction is the piece I was missing.

My earlier numbers came from the wrong binary: I was loading runtime.node from this sandbox's own CLI install (1.0.81-11) rather than the 1.0.84-4 this PR pins. Against the pinned runtime I see the documented behavior, and the timing matches your explanation exactly — with a callback blocked in koffi's threadsafe relay, the first close returned false at +5001ms, i.e. the full quiescence budget, then true on retry 100ms later.

The Rust point is well made. I had it backwards: the counter increments inside on_outbound after native has already handed over the raw pointer, so close can observe zero and free the box while a callback is paused before its first increment. Moving the tracking to the gate, where in_flight is recorded before the host is invoked, closes the window the counter never covered. Agreed that keeping it would be redundant after a successful close and wouldn't repair the pre-increment race.

Agreed too on not adding a timing-based E2E that passes on both implementations — a test that can't distinguish the branch under test isn't worth its runtime, and the deterministic window genuinely needs a native instrumentation hook. The false-then-true adapter tests are the right level for the host side of the contract.

LGTM.

@stephentoub
stephentoub added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 51cf90d Sep 11, 2026
363 of 367 checks passed
@stephentoub
stephentoub deleted the stephentoub-fix-ffi-callback-teardown branch September 11, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants